為了讓使用者輕鬆管理家中的物品並清楚掌握存放位置,我們加入地點管理功能。今天我們將實作地點管理功能,讓使用者能夠方便地查看、刪除和新增地點。準備好了嗎?讓我們開始吧!
以下為我們今天要實作的目標:
和昨天一樣,我們先建立 ViewModel。這個 ViewModel 負責新增、查詢、刪除地點資料,我們需要在 ViewModel 中實作以下功能:
locations
,用來儲存從資料庫中抓取的所有地點。showSuccessToast
,當新增地點成功時,觸發這個變數來顯示通知。failHandle
,用來處理錯誤訊息,當新增或刪除失敗時,顯示錯誤提示。fetchLocations()
,用來從資料庫中載入所有已存在的地點。addLocation(name: String, colorHex: String)
,負責將使用者輸入的名稱和顏色存入 Core Data。deleteLocation( location: Location)
,負責刪除指定的地點,並在刪除成功後重新抓取資料。如果刪除失敗,則會顯示錯誤訊息。下面是 LocationListViewModel 完整的程式碼
import SwiftUI
class LocationListViewModel: ObservableObject {
@Published var locations: [Location] = []
@Published var showSuccessToast: Bool = false
@Published var failHandle: (isFail: Bool, title: String) = (isFail: false, title: "")
private let dataManager: DataManager
init(dataManager: DataManager = DataManager()) {
self.dataManager = dataManager
fetchLocations()
}
func fetchLocations() {
locations = dataManager.fetchLocations()
}
func addLocation(name: String, colorHex: String) {
if name.isEmpty || colorHex.isEmpty {
failHandle = (isFail: true, title: "您有欄位尚未輸入")
} else {
let result = dataManager.addLocation(name: name, colorHex: colorHex)
if result {
fetchLocations()
} else {
failHandle = (isFail: true, title: "發生錯誤")
}
}
}
func deleteLocation(_ location: Location) {
let result = dataManager.deleteLocation(location)
if !result {
failHandle = (isFail: true, title: "發生錯誤")
} else {
fetchLocations()
}
}
}
因為我們等等會需要在 LocationListView 呼叫 AddLocationModalView 頁面,因此我們先來實作 AddLocationModalView 吧!
AddLocationModalView 是用來新增地點的彈跳視窗,使用者可以在這裡輸入地點名稱並選擇顏色。
在 AddLocationModalView 中,作法和 Day14 在製作側邊欄時有點相像。需要先製作出一個半透明的黑色背景,用來當作遮罩。這樣的效果像是在模擬跳出 alert 的感覺,讓使用者的注意力放在跳出的視窗當中。
我們需要新增變數 isPresented
控制彈跳視窗的顯示,並且利用 ZStack
來設定背景顏色。透過 onTapGesture
這個 Modifier 來增加背景的點擊事件,讓使用者可以點選背景就關閉彈跳視窗。
struct AddLocationModalView: View {
@Binding var isPresented: Bool // 控制彈跳視窗的顯示
var body: some View {
ZStack {
// 背景遮罩
Color.black.opacity(0.4)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
isPresented = false // 點擊背景關閉彈跳視窗
}
}
}
}
在建立元件之前,我們必須要將這些元件會使用到的變數先建立好:
@ObservedObject var viewModel: LocationListViewModel
@State private var locationName: String = ""
@State private var selectedColorHex: String = "#FF5733"
let colors: [String] = [
"#FF5733", "#FF8D1A", "#FFC300", "#DAF7A6",
"#33FF57", "#33FFF9", "#3380FF", "#9D33FF",
"#FF33B5", "#F39C12", "#2ECC71", "#3498DB"
]
let columns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
接著照著目標的示意圖,建立需要的元件:TextField、Text、Button 等。
var body: some View {
ZStack {
// 背景遮罩
Color.black.opacity(0.4)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
isPresented = false // 點擊背景關閉彈跳視窗
}
// 彈跳視窗的內容
VStack {
Text("新增地點")
.font(.headline)
.frame(maxWidth: .infinity)
// 地點名稱輸入框
TextField("輸入地點名稱", text: $locationName)
.padding()
.background(Color.gray.opacity(0.2))
.cornerRadius(10)
.padding(.horizontal)
// 顏色選擇區
LazyVGrid(columns: columns, spacing: 12) {
ForEach(colors, id: \.self) { colorHex in
Button(action: {
selectedColorHex = colorHex
}) {
Circle()
.fill(Color(hexString: colorHex) ?? .black)
.frame(width: 40, height: 40)
.overlay(
Circle()
.stroke(Color.black, lineWidth: selectedColorHex == colorHex ? 2 : 0)
)
}
}
}
.padding()
Button(action: {
viewModel.addLocation(name: locationName, colorHex: selectedColorHex)
}) {
Text("新增")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.buttonStyle(PlainButtonStyle())
}
.padding()
.frame(width: 360)
.background(Color.white)
.cornerRadius(20)
.shadow(radius: 20)
}
}
目前的畫面會像上面的圖片一樣,這時候會發現,還少了一個「X」的按鈕。我們試著將它新增上去:
這邊我們必須要使用 ZStack
將「新增地點」這個 Text
和「X」按鈕包起來,因為我們想要讓「X」按鈕和「新增地點」水平垂直,並且「新增地點」文字必須和整個畫面置中。
ZStack {
Text("新增地點")
.font(.headline)
.frame(maxWidth: .infinity)
HStack() {
Spacer()
Button(action: {
isPresented = false
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
.font(.title2)
}
}
}
這樣做就會像目標一樣,在右上角建立一個「X」的按鈕。
這裡使用 AlertToast
來提示使用者新增地點成功或失敗,如果忘記 AlertToast
怎麼使用,可以回到 Day12 複習一下唷!
.toast(isPresenting: $viewModel.showSuccessToast, alert: {
AlertToast(type: .complete(Color.green), title: "完成")
}, completion: {
isPresented = false
})
.toast(isPresenting: $viewModel.failHandle.isFail, alert: {
AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
})
到這邊為止,我們的 AddLocationModalView 就完成了,下面附上完整程式碼:
import SwiftUI
import AlertToast
struct AddLocationModalView: View {
@Binding var isPresented: Bool
@ObservedObject var viewModel: LocationListViewModel
@State private var locationName: String = ""
@State private var selectedColorHex: String = "#FF5733"
let colors: [String] = [
"#FF5733", "#FF8D1A", "#FFC300", "#DAF7A6",
"#33FF57", "#33FFF9", "#3380FF", "#9D33FF",
"#FF33B5", "#F39C12", "#2ECC71", "#3498DB"
]
let columns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
ZStack {
Color.black.opacity(0.4)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
isPresented = false
}
VStack {
ZStack {
Text("新增地點")
.font(.headline)
.frame(maxWidth: .infinity)
HStack() {
Spacer()
Button(action: {
isPresented = false
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
.font(.title2)
}
}
}
TextField("輸入地點名稱", text: $locationName)
.padding()
.background(Color.gray.opacity(0.2))
.cornerRadius(10)
.padding(.horizontal)
LazyVGrid(columns: columns, spacing: 12) {
ForEach(colors, id: \.self) { colorHex in
Button(action: {
selectedColorHex = colorHex
}) {
Circle()
.fill(Color(hexString: colorHex) ?? .black)
.frame(width: 40, height: 40)
.overlay(
Circle()
.stroke(Color.black, lineWidth: selectedColorHex == colorHex ? 2 : 0)
)
}
}
}
.padding()
Button(action: {
viewModel.addLocation(name: locationName, colorHex: selectedColorHex)
}) {
Text("新增")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.buttonStyle(PlainButtonStyle())
}
.padding()
.frame(width: 360)
.background(Color.white)
.cornerRadius(20)
.shadow(radius: 20)
}
.toast(isPresenting: $viewModel.showSuccessToast, alert: {
AlertToast(type: .complete(Color.green), title: "完成")
}, completion: {
isPresented = false
})
.toast(isPresenting: $viewModel.failHandle.isFail, alert: {
AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
})
}
}
#Preview {
AddLocationModalView(isPresented: .constant(true), viewModel: LocationListViewModel())
}
在 LocationListView 中,我們使用 List
顯示每個列表資料,其中包含顏色和地點名稱。
在 LocationListView 中,我們透過 State 變數來控制新增地點視窗是否顯示 (isShowingAddLocationView),並且使用 @ObservedObject
監聽 LocationListViewModel 以進行管理地點資料。
並且新建 List
來顯示地點資料,利用 onDelete
這個 Modifier 實現刪除的行為。List
的相關用法在 Day9 和 Day10 有解釋過,需要的讀者可以先回去看一下唷!
struct LocationListView: View {
@State private var isShowingAddLocationView = false
@ObservedObject private var viewModel: LocationListViewModel
init(viewModel: LocationListViewModel = LocationListViewModel()) {
self.viewModel = viewModel
}
var body: some View {
NavigationView {
List {
ForEach(viewModel.locations, id: \.id) { location in
HStack {
// 顯示地點顏色
Circle()
.fill(Color(hexString: location.colorHex) ?? .black)
.frame(width: 30, height: 30)
// 顯示地點名稱
Text(location.name)
.font(.body)
.padding(.leading, 10)
Spacer()
}
.padding(.vertical, 8)
}
.onDelete { indexSet in
indexSet.forEach { index in
let location = viewModel.locations[index]
viewModel.deleteLocation(location)
}
}
}
}
}
}
透過 toolbar
,就可以在右上角新增一個 "加號" 按鈕,當點擊時,會顯示新增地點的彈出視窗。
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
isShowingAddLocationView = true
}) {
Image(systemName: "plus")
.font(.title2)
}
}
}
使用 overlay
Modifier 將 AddLocationModalView 顯示出來。
.overlay(
Group {
if isShowingAddLocationView {
AddLocationModalView(isPresented: $isShowingAddLocationView, viewModel: viewModel)
}
}
)
這樣就大功告成了,以下是 LocationListView 完整的程式碼:
import SwiftUI
struct LocationListView: View {
@State private var isShowingAddLocationView = false
@ObservedObject private var viewModel: LocationListViewModel
init(viewModel: LocationListViewModel = LocationListViewModel()) {
self.viewModel = viewModel
}
var body: some View {
NavigationView {
List {
ForEach(viewModel.locations, id: \.id) { location in
HStack {
// 顯示地點顏色
Circle()
.fill(Color(hexString: location.colorHex) ?? .black)
.frame(width: 30, height: 30)
// 顯示地點名稱
Text(location.name)
.font(.body)
.padding(.leading, 10)
Spacer()
}
.padding(.vertical, 8)
}
.onDelete { indexSet in
indexSet.forEach { index in
let location = viewModel.locations[index]
viewModel.deleteLocation(location)
}
}
}
.listStyle(InsetGroupedListStyle())
.navigationTitle("地點列表")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
isShowingAddLocationView = true
}) {
Image(systemName: "plus")
.font(.title2)
}
}
}
.overlay(
Group {
if isShowingAddLocationView {
AddLocationModalView(isPresented: $isShowingAddLocationView, viewModel: viewModel)
}
}
)
}
}
}
#Preview {
LocationListView()
}
今天完成地點列表功能的實作,讓使用者可以查看、刪除和新增家中的地點。並且透過彈跳視窗,方便使用者輸入地點名稱並選擇顏色。明天,我們將把分類管理與地點管理功能整合進側邊欄與家用品資料中。